Skip to content

apple: AES-GCM-wrap the SE dataRepresentation under a Keychain key#65

Merged
jgowdy-godaddy merged 1 commit intomainfrom
feat/macos-handle-wrapping
Apr 17, 2026
Merged

apple: AES-GCM-wrap the SE dataRepresentation under a Keychain key#65
jgowdy-godaddy merged 1 commit intomainfrom
feat/macos-handle-wrapping

Conversation

@jgowdy-godaddy
Copy link
Copy Markdown
Contributor

Summary

Implements Steps 1-6 of `fix-macos.md`: close the same-UID `.handle` theft threat that made the SE backend's security promise leaky on Homebrew / cargo-install distribution.

Before: the SE `dataRepresentation` returned by CryptoKit was written to `.handle` as plaintext (0600). A same-UID attacker could copy the file and drive SE operations as the user from another process. The SE key itself still never exported, but that's useless when an attacker can force SE operations at will.

After: the handle is AES-256-GCM sealed under a 32-byte key held in the login keychain. Reading the handle is now gated by the keychain's code-signature-bound ACL — access from a different binary prompts the user; access after a rebuild (new code hash) prompts once and persists until the next rebuild.

What landed

  1. Swift bridge helpers (`bridge.swift`): `enclaveapp_keychain_store` / `_load` / `_delete` wrap `kSecClassGenericPassword` items in the legacy login keychain (modern Data Protection keychain fails with `-34018` on unsigned builds).
  2. Rust FFI declarations (`ffi.rs`) for the three helpers.
  3. New `keychain_wrap.rs` module with:
    • File format `[EHW1 magic (4)] [nonce (12)] [ciphertext] [tag (16)]`
    • `generate_wrapping_key` / `encrypt_blob` / `decrypt_blob` over `aes-gcm` + `rand::rngs::OsRng`
    • Thin `keychain_store` / `_load` / `_delete` wrappers
  4. `generate_and_save_key`: generates a fresh wrapping key, stores it, encrypts the `dataRepresentation`, persists the sealed blob. Rollback paths on every failure so labels stay reusable.
  5. `load_handle`: detects `EHW1` magic → unwrap via keychain key. Legacy plaintext `.handle` files return unchanged for transparent migration (they re-wrap on the next rotation — no on-disk rewrite required to keep existing keys working).
  6. `delete_key`: also removes the keychain wrapping-key entry. Best-effort; a stale keychain entry without its handle is inert.

Testing

29 tests covering the new surface:

  • 20 pure-Rust AES/wrap tests: empty/short/long round-trip, magic prefix, same-plaintext-twice nonce distinctness, tamper detection on ciphertext / tag / nonce / magic / truncation, wrong key, legacy-plaintext rejection.
  • 9 real-Keychain integration tests (macOS, default in `cargo test`): basic round-trip, missing-entry returns `None`, idempotent upsert, idempotent delete, delete actually removes, per-label isolation, per-app isolation, end-to-end wrap + keychain + unwrap lifecycle, swapped-wrapping-key decrypt fails.

Each integration test uses a test-unique service/account pair and a RAII `KeychainEntryGuard` so a failing test never leaks a keychain entry.

Scope / deferred

Steps 7-8 (entitled Path 1 — `SecKeyCreateRandomKey` with `kSecAttrTokenIDSecureEnclave`) are deferred. They require a provisioning profile that self-signed and ad-hoc-signed binaries cannot obtain; testing the entitled path needs a signed build with AMFI-approved profile. For Homebrew / cargo-install distribution (the common case), Path 2 (this PR) is sufficient.

`THREAT_MODEL.md`, `DESIGN.md`, and `fix-macos.md` all updated to reflect the new reality.

Test plan

  • `cargo test --workspace` on macOS — 35 tests in `enclaveapp-apple` (including the 9 Keychain integration tests) pass; whole workspace green.
  • `cargo clippy --workspace --all-targets -- -D warnings` clean.
  • `cargo fmt --all -- --check` clean.
  • CI green on macOS / Linux / Windows.

Implements Steps 1-6 of fix-macos.md: close the same-UID `.handle`
theft threat that made the SE backend's security promise leaky on
Homebrew / cargo-install distribution.

Before this change: the SE `dataRepresentation` returned by CryptoKit
was written to `<label>.handle` as plaintext (0600). Same-UID attacker
copies the file → can drive SE operations as the user from another
process. SE key itself still never exports, but that distinction is
useless when the attacker can force SE operations at will.

After this change:

1. Swift bridge gains keychain_store / keychain_load / keychain_delete
   helpers that wrap kSecClassGenericPassword items in the login
   keychain. Uses legacy (not Data Protection) keychain because the
   modern one fails with -34018 on unsigned builds.

2. Rust FFI declarations for the three new helpers.

3. New module keychain_wrap.rs with:
     - WRAP_MAGIC (b"EHW1") + 32-byte key + 12-byte nonce + 16-byte tag
       file format
     - generate_wrapping_key / encrypt_blob / decrypt_blob using
       aes-gcm + rand::rngs::OsRng
     - keychain_store / keychain_load / keychain_delete thin wrappers
       around the FFI

4. generate_and_save_key: generates a wrapping key, stores it in the
   keychain, AES-GCM-encrypts the dataRepresentation, and persists
   the sealed blob. On any failure after the SE key is minted, rolls
   back the SE key + keychain entry so labels stay reusable.

5. load_handle: detects EHW1 magic and unwraps via the keychain key;
   falls back to returning raw bytes for legacy plaintext `.handle`
   files (transparent backward-compat migration — no on-disk rewrite
   required to keep existing keys working).

6. delete_key: also removes the keychain wrapping-key entry.
   Best-effort — a stale entry without its handle is inert.

## Testing

29 tests covering the new code:

- 20 pure-Rust unit tests for AES-GCM wrap/unwrap: empty / short /
  long plaintext, magic prefix, round-trip consistency across
  independent calls, tamper detection on ciphertext / tag / nonce,
  truncation, wrong key, missing magic, legacy-plaintext rejection.
- 9 real-Keychain integration tests (macOS only, run by default):
  basic round-trip, missing-entry returns None, store is idempotent
  upsert, delete is idempotent, delete actually removes,
  per-label isolation, per-app isolation, full wrap + keychain +
  unwrap lifecycle, swapped-wrapping-key decrypt fails.

All tests use unique test-keyed service/account pairs and a RAII
KeychainEntryGuard so a failing test never leaks a keychain entry.

## Scope / deferred

Steps 7-8 (entitled Path 1 — SecKeyCreateRandomKey with
kSecAttrTokenIDSecureEnclave) are deferred. They require a
provisioning profile that self-signed and ad-hoc-signed binaries
cannot obtain; testing the entitled path needs a signed build with
AMFI-approved profile. For distribution via Homebrew / cargo
install, Path 2 (this PR) is sufficient.

THREAT_MODEL, DESIGN, fix-macos.md all updated to match the new
reality.
@jgowdy-godaddy jgowdy-godaddy merged commit 9489c3e into main Apr 17, 2026
3 checks passed
jgowdy-godaddy pushed a commit that referenced this pull request Apr 17, 2026
fix-macos.md's implementation plan is complete:
- Steps 1-6 (Path 2: AES-GCM-wrapped .handle + keychain-held wrapping key)
  are shipped in PR #65.
- Steps 7-8 (Path 1: entitled SecKeyCreateRandomKey with
  kSecAttrTokenIDSecureEnclave) are blocked on a provisioning profile, not
  deferred work. The required keychain-access-groups entitlement is
  AMFI-restricted and unavailable to Homebrew / cargo-install distribution,
  so these steps will not land under the current distribution model.

The still-useful content from the plan doc has been migrated into
THREAT_MODEL's macOS platform-specific notes:
- Full prompt-behavior matrix (ad-hoc vs self-signed vs trusted-cert,
  first run / rebuild / different path)
- Deny / Always Allow / upgrade-transition behavior
- -34018 finding explaining why the legacy keychain is used
- Explicit note that the entitled SE path is blocked on provisioning

fix-macos.md is deleted.
jgowdy-godaddy added a commit that referenced this pull request Apr 17, 2026
fix-macos.md's implementation plan is complete:
- Steps 1-6 (Path 2: AES-GCM-wrapped .handle + keychain-held wrapping key)
  are shipped in PR #65.
- Steps 7-8 (Path 1: entitled SecKeyCreateRandomKey with
  kSecAttrTokenIDSecureEnclave) are blocked on a provisioning profile, not
  deferred work. The required keychain-access-groups entitlement is
  AMFI-restricted and unavailable to Homebrew / cargo-install distribution,
  so these steps will not land under the current distribution model.

The still-useful content from the plan doc has been migrated into
THREAT_MODEL's macOS platform-specific notes:
- Full prompt-behavior matrix (ad-hoc vs self-signed vs trusted-cert,
  first run / rebuild / different path)
- Deny / Always Allow / upgrade-transition behavior
- -34018 finding explaining why the legacy keychain is used
- Explicit note that the entitled SE path is blocked on provisioning

fix-macos.md is deleted.

Co-authored-by: Jay Gowdy <jay@gowdy.me>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants